Python GIL
作者:Tonner Python爱好者社区专栏作者
简书专栏:https://www.jianshu.com/u/0812b416fab9
在这篇博文中,我们将介绍Python GIL
, Threads
, Processes
和AsyncIO
假设我们想要编写一个函数,该函数将数字作为参数并简单的倒计时,挺容易:
def count_down(n):
while n > 0:
n -= 1
让我们用一个大数字来调用这个函数并测量函数耗时:
from time import time
before = time()
count_down(100000000)
after = time()
print(after - before)
在我的机器上,需要5.62秒。现在,让我们调用它两次并测量耗时
from time import time
before = time()
count_down(100000000)
count_down(100000000)
after = time()
print(after - before)
在我的机器上,需要11.58秒
它正常工作,但是我们的老板不满意,他希望执行的更快。我们如何让它更快?我们使用多线程使这两次函数调用并行运行。理论上,并行运行函数两次应该花费函数一次执行的时间,因为函数的调用是并行执行的。让我们通过在不同的线程中调用这个函数两次:
from threading import Threadfrom time import time
before = time()
threads = []for item in range(1, 3):
t = Thread(target=count_down, args=(100000000,))
t.start()
threads.append(t)
[thread.join() for thread in threads]
after = time()
print(after - before)
我的机器大约需要11.2秒。等等,什么?它不仅没有之前的快,但实际上它甚至比不使用多线程更慢。使用Python2.7你会得到更糟糕的结果。在一个着名的软件开发人员David Beazly报告(inside the Python GIl)中提到:它甚至花费几乎两倍的时间
But, WHY?
请欢迎臭名昭著的GIL(Global Interpreter Lock). 这家伙应该为Python世界上所有的事情负责。GIL在同一时间只允许一个线程运行。在上面的例子中,当我们认为我们使用了两个线程,我们实际只是用了一个,因为GIL不允许我们这么做。这就是它无法运行得更快的原因。但是,为什么它甚至更慢?因为Python试图不停的切换线程让两个线程同时工作。GIL不允许这么做。然而,无用的切换会带来额外的开销。因此,最终结果甚至更慢。
But, WHY?
我们都知道多线程是个好东西,可以帮我们让程序运行更快。 为什么有GIL的存在呢?该死的,GIL就像行尸走肉中的Negan
好吧,事实证明GIL并不是一个恶棍。实际上,GIL是一个好东西。实际上Python的内存管理不是线程安全的!也就是说,如果你同时运行多个线程,你的程序会出现各种莫名其妙的现象甚至是灾难性的。GIL可以帮我们摆脱这种情况。
但是,删除Thread类不是更容易吗?
很好的问题!既然想到要编写一个额外的工具来防止Threads
对我们的程序产生不好影响,为什么不直接删除Threads
类,让程序员根本无法使用它们呢?
实际上,有些情况Thread
很有用。以上的示例都是CPU密集型计算,意味着它们只需在CPU上运行并且在CPU上等待。但是,如果你的代码是IO密集型计算或者是图像处理或者是NumPy数字运算,GIL将不会妨碍,因为这些操作发生在GIL之外。在这些地方你可以安全有效的使用Thread
让我们编写一个IO密集型计算的函数,函数完成请求一个url并将结果作为文本返回。
import requests
def get_content(url):
response = requests.get(url)
return response.text
在我的机器上,使用参数https://google.com
调用一次此函数需要1.1 秒,调用两次需要2.1秒。现在让我们在不同的线程中调用此函数两次,看看Thread
的效果。
urls = ['https://google.com'] * 2
before = time()
threads = []
for url in urls:
thread1 = Thread(target=get_content, args=(url,))
thread1.start()
threads.append(thread1)
[thread.join() for thread in threads]
after = time()
print(after - before)
只需要1.15秒,万岁!在不同的线程中调用两次相当于调用一次的时间。因为我们的函数是IO密集型计算。当我们的函数是CPU密集型计算(之前的示例中函数完成递减就是CPU密集型计算)时同样的事情就不会发生。
还有一个问题
每一个线程需要一些额外的内存,线程切换需要一些时间。虽然不是很多,但是当你运行数千个线程的时候资源的消耗就会增加。想象一下数千兆字节的额外RAM和至少5%的CPU时间仅用于上下文切换。
为了解决这个问题,Python开发人员提供了一个asyncio库。它有自己的事件循环来控制单个线程内异步方式的函数执行。如果线程被底层的操作系统控制,asyncio
知道何时借助于开发人员自己编写的关键词切换任务。没错!你来决定何时切换。让我们将上面的示例转换成asyncio
实现:
import asyncio
import aiohttp
loop = asyncio.get_event_loop()
session = aiohttp.ClientSession(loop=loop)
async def get_content(pid, url):
async with session.get(url) as response:
content = await response.read()
print(pid, content)
loop.create_task(get_content(1, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(2, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(3, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(4, 'http://asyncio.readthedocs.io/'))
loop.create_task(get_content(5, 'http://asyncio.readthedocs.io/'))
loop.run_forever()
注意我们使用了aiohttp
代替了requests
,因为它是一个异步的HTTP包。在不涉及细节的情况下,这段代码做所的是定义一个异步函数(aka协成)并使用不同的ID(仅仅是为了说明其异步性)调用五次,输出应该是这样的:
1 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
3 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
4 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
5 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
2 b'\n<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0
请注意,ID不是按照他们的调用顺序来显示的,也就是说当遇到关键词await
时任务被切换了。我们可以使用Thread
得到相同的结果,但是asyncio
使用了较少的开销并且您控制切换过程。
虽然这个示例没有解释asyncio
的细节, 但我希望它至少能解释为什么它是有用的。
好了,我们现在处理了IO密集型任务,让我们回到我们第一个CPU密集型计算的问题。
CPU密集型计算任务的解决方式
在Python
中,multiprocessing.Process
提供了和Thread
相似的功能和接口。区别是它使用了子进程而不是线程。这就是它不会被GIL阻塞的原因。太棒了!让我们使用Process
重写上面的示例。正如我所说,Process
提供了和Thread
形似的接口,我们只需要把Thread
替换成Process
即可完成这个示例:
from multiprocessing import Process
from time import time
def count_down(n):
while n > 0:
n -= 1
before = time()
processes = []
for item in range(1, 2):
process1 = Process(target=count_down, args=(100000000,))
process1.start()
processes.append(process1)
[process.join() for process in processes]
after = time()
print(after - before)
我的机器花了11秒。这是我们之前调用一次函数的时间。酷!试着再添加一个进程仍然需要同样的时间。太棒了!但是,请记住,进程在一个单独的内容空间运行,因此不能彼此共享数据,而线程可以。
我们这里谈的是Cpython
。还有一些其他的实现,像Jpython
和IronPython
没有GIL
,所以可以直接使用线程。如果你不知道Jpython
或者IronPython
是什么,请自行GOOGLE,这里有一篇博文仅供参考:What is Python?
http://rahmonov.me/posts/what-is-python/
感谢阅读,如果有任何问题,请留下评论。
Fight on!
Python爱好者社区历史文章大合集:
Python爱好者社区历史文章列表(每周append更新一次)
关注后在公众号内回复“课程”即可获取:
小编的Python入门免费视频课程!!!
【最新免费微课】小编的Python快速上手matplotlib可视化库!!!
崔老师爬虫实战案例免费学习视频。
陈老师数据分析报告制作免费学习视频。
玩转大数据分析!Spark2.X+Python 精华实战课程免费学习视频。